/*
 * Copyright 2014 The Netty Project
 *
 * The Netty Project licenses this file to you under the Apache License,
 * version 2.0 (the "License"); you may not use this file except in compliance
 * with the License. You may obtain a copy of the License at:
 *
 *   https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations
 * under the License.
 */
package io.netty5.handler.ssl.util;

import io.netty5.buffer.Buffer;
import io.netty5.handler.codec.base64.Base64;
import io.netty5.util.internal.ObjectUtil;
import io.netty5.util.internal.PlatformDependent;
import io.netty5.util.internal.SystemPropertyUtil;
import org.jetbrains.annotations.TestOnly;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Date;

import static io.netty5.buffer.DefaultBufferAllocators.onHeapAllocator;
import static java.nio.charset.StandardCharsets.US_ASCII;

/**
 * Generates a temporary self-signed certificate for testing purposes.
 * <p>
 * <strong>NOTE:</strong>
 * Never use the certificate and private key generated by this class in production.
 * It is purely for testing purposes, and thus it is very insecure.
 * It even uses an insecure pseudo-random generator for faster generation internally.
 * </p><p>
 * An X.509 certificate file and a EC/RSA private key file are generated in a system's temporary directory using
 * {@link File#createTempFile(String, String)}, and they are deleted when the JVM exits using
 * {@link File#deleteOnExit()}.
 * </p><p>
 * The certificate is generated using <a href="https://www.bouncycastle.org/">Bouncy Castle</a>, which is an
 * <em>optional</em> dependency of Netty.
 * </p>
 */
@TestOnly
public final class SelfSignedCertificate {

    private static final Logger logger = LoggerFactory.getLogger(SelfSignedCertificate.class);

    /** Current time minus 1 year, just in case software clock goes back due to time synchronization */
    private static final Date DEFAULT_NOT_BEFORE = new Date(SystemPropertyUtil.getLong(
            "io.netty5.selfSignedCertificate.defaultNotBefore", System.currentTimeMillis() - 86400000L * 365));
    /** The maximum possible value in X.509 specification: 9999-12-31 23:59:59 */
    private static final Date DEFAULT_NOT_AFTER = new Date(SystemPropertyUtil.getLong(
            "io.netty5.selfSignedCertificate.defaultNotAfter", 253402300799000L));

    /**
     * FIPS 140-2 encryption requires the RSA key length to be 2048 bits or greater.
     * Let's use that as a sane default but allow the default to be set dynamically
     * for those that need more stringent security requirements.
     */
    private static final int DEFAULT_KEY_LENGTH_BITS =
            SystemPropertyUtil.getInt("io.netty5.handler.ssl.util.selfSignedKeyStrength", 2048);

    private final File certificate;
    private final File privateKey;
    private final X509Certificate cert;
    private final PrivateKey key;

    /**
     * Creates a new instance.
     * <p> Algorithm: RSA </p>
     */
    public SelfSignedCertificate() throws CertificateException {
        this(new Builder());
    }

    /**
     * Creates a new instance.
     * <p> Algorithm: RSA </p>
     *
     * @param notBefore Certificate is not valid before this time
     * @param notAfter  Certificate is not valid after this time
     */
    public SelfSignedCertificate(Date notBefore, Date notAfter)
            throws CertificateException {
        this(new Builder().notBefore(notBefore).notAfter(notAfter));
    }

    /**
     * Creates a new instance.
     *
     * @param notBefore Certificate is not valid before this time
     * @param notAfter  Certificate is not valid after this time
     * @param algorithm Key pair algorithm
     * @param bits      the number of bits of the generated private key
     */
    public SelfSignedCertificate(Date notBefore, Date notAfter, String algorithm, int bits)
            throws CertificateException {
        this(new Builder().notBefore(notBefore).notAfter(notAfter).algorithm(algorithm).bits(bits));
    }

    /**
     * Creates a new instance.
     * <p> Algorithm: RSA </p>
     *
     * @param fqdn a fully qualified domain name
     */
    public SelfSignedCertificate(String fqdn) throws CertificateException {
        this(new Builder().fqdn(fqdn));
    }

    /**
     * Creates a new instance.
     *
     * @param fqdn      a fully qualified domain name
     * @param algorithm Key pair algorithm
     * @param bits      the number of bits of the generated private key
     */
    public SelfSignedCertificate(String fqdn, String algorithm, int bits) throws CertificateException {
        this(new Builder().fqdn(fqdn).algorithm(algorithm).bits(bits));
    }

    /**
     * Creates a new instance.
     * <p> Algorithm: RSA </p>
     *
     * @param fqdn      a fully qualified domain name
     * @param notBefore Certificate is not valid before this time
     * @param notAfter  Certificate is not valid after this time
     */
    public SelfSignedCertificate(String fqdn, Date notBefore, Date notAfter) throws CertificateException {
        this(new Builder().fqdn(fqdn).notBefore(notBefore).notAfter(notAfter));
    }

    /**
     * Creates a new instance.
     *
     * @param fqdn      a fully qualified domain name
     * @param notBefore Certificate is not valid before this time
     * @param notAfter  Certificate is not valid after this time
     * @param algorithm Key pair algorithm
     * @param bits      the number of bits of the generated private key
     */
    public SelfSignedCertificate(String fqdn, Date notBefore, Date notAfter, String algorithm, int bits)
            throws CertificateException {
        this(new Builder().fqdn(fqdn).notBefore(notBefore).notAfter(notAfter).algorithm(algorithm).bits(bits));
    }

    /**
     * Creates a new instance.
     * <p> Algorithm: RSA </p>
     *
     * @param fqdn      a fully qualified domain name
     * @param random    the {@link SecureRandom} to use
     * @param bits      the number of bits of the generated private key
     */
    public SelfSignedCertificate(String fqdn, SecureRandom random, int bits)
            throws CertificateException {
        this(new Builder().fqdn(fqdn).random(random).bits(bits));
    }

    /**
     * Creates a new instance.
     *
     * @param fqdn      a fully qualified domain name
     * @param random    the {@link SecureRandom} to use
     * @param algorithm Key pair algorithm
     * @param bits      the number of bits of the generated private key
     */
    public SelfSignedCertificate(String fqdn, SecureRandom random, String algorithm, int bits)
            throws CertificateException {
        this(new Builder().fqdn(fqdn).random(random).algorithm(algorithm).bits(bits));
    }

    /**
     * Creates a new instance.
     * <p> Algorithm: RSA </p>
     *
     * @param fqdn      a fully qualified domain name
     * @param random    the {@link SecureRandom} to use
     * @param bits      the number of bits of the generated private key
     * @param notBefore Certificate is not valid before this time
     * @param notAfter  Certificate is not valid after this time
     */
    public SelfSignedCertificate(String fqdn, SecureRandom random, int bits, Date notBefore, Date notAfter)
            throws CertificateException {
        this(new Builder().fqdn(fqdn).notBefore(notBefore).notAfter(notAfter).random(random).bits(bits));
    }

    /**
     * Creates a new instance.
     *
     * @param fqdn      a fully qualified domain name
     * @param random    the {@link SecureRandom} to use
     * @param bits      the number of bits of the generated private key
     * @param notBefore Certificate is not valid before this time
     * @param notAfter  Certificate is not valid after this time
     * @param algorithm Key pair algorithm
     */
    public SelfSignedCertificate(String fqdn, SecureRandom random, int bits, Date notBefore, Date notAfter,
                                 String algorithm) throws CertificateException {
        this(new Builder().fqdn(fqdn).random(random).algorithm(algorithm).bits(bits)
                .notBefore(notBefore).notAfter(notAfter));
    }

    private SelfSignedCertificate(Builder builder) throws CertificateException {
        if (!builder.generateBc()) {
            if (!builder.generateKeytool()) {
                throw new CertificateException(
                        "No provider succeeded to generate a self-signed certificate. " +
                                "See debug log for the root cause.", builder.failure);
            }
        }

        certificate = new File(builder.paths[0]);
        privateKey = new File(builder.paths[1]);
        key = builder.privateKey;
        try (FileInputStream certificateInput = new FileInputStream(certificate)) {
            cert = (X509Certificate) CertificateFactory.getInstance("X509").generateCertificate(certificateInput);
        } catch (Exception e) {
            throw new CertificateEncodingException(e);
        }
    }

    public static Builder builder() {
        return new Builder();
    }

    /**
     * Returns the generated X.509 certificate file in PEM format.
     */
    public File certificate() {
        return certificate;
    }

    /**
     * Returns the generated EC/RSA private key file in PEM format.
     */
    public File privateKey() {
        return privateKey;
    }

    /**
     *  Returns the generated X.509 certificate.
     */
    public X509Certificate cert() {
        return cert;
    }

    /**
     * Returns the generated EC/RSA private key.
     */
    public PrivateKey key() {
        return key;
    }

    /**
     * Deletes the generated X.509 certificate file and EC/RSA private key file.
     */
    public void delete() {
        safeDelete(certificate);
        safeDelete(privateKey);
    }

    static String[] newSelfSignedCertificate(
            String fqdn, PrivateKey key, X509Certificate cert) throws IOException, CertificateEncodingException {
        // Encode the private key into a file.
        final String keyText;
        try (Buffer wrappedBuf = onHeapAllocator().copyOf(key.getEncoded());
             Buffer encodedBuf = Base64.encode(wrappedBuf, true)) {
            keyText = "-----BEGIN PRIVATE KEY-----\n" +
                      encodedBuf.toString(US_ASCII) +
                      "\n-----END PRIVATE KEY-----\n";
        }

        // Change all asterisk to 'x' for file name safety.
        fqdn = fqdn.replaceAll("[^\\w.-]", "x");

        File keyFile = PlatformDependent.createTempFile("keyutil_" + fqdn + '_', ".key", null);
        keyFile.deleteOnExit();

        OutputStream keyOut = new FileOutputStream(keyFile);
        try {
            keyOut.write(keyText.getBytes(US_ASCII));
            keyOut.close();
            keyOut = null;
        } finally {
            if (keyOut != null) {
                safeClose(keyFile, keyOut);
                safeDelete(keyFile);
            }
        }

        final String certText;
        try (Buffer wrappedBuf = onHeapAllocator().copyOf(cert.getEncoded());
             Buffer encodedBuf = Base64.encode(wrappedBuf, true)) {
            // Encode the certificate into a CRT file.
            certText = "-----BEGIN CERTIFICATE-----\n" +
                       encodedBuf.toString(US_ASCII) +
                       "\n-----END CERTIFICATE-----\n";
        }

        File certFile = PlatformDependent.createTempFile("keyutil_" + fqdn + '_', ".crt", null);
        certFile.deleteOnExit();

        OutputStream certOut = new FileOutputStream(certFile);
        try {
            certOut.write(certText.getBytes(US_ASCII));
            certOut.close();
            certOut = null;
        } finally {
            if (certOut != null) {
                safeClose(certFile, certOut);
                safeDelete(certFile);
                safeDelete(keyFile);
            }
        }

        return new String[] { certFile.getPath(), keyFile.getPath() };
    }

    private static void safeDelete(File certFile) {
        if (!certFile.delete()) {
            if (logger.isWarnEnabled()) {
                logger.warn("Failed to delete a file: " + certFile);
            }
        }
    }

    private static void safeClose(File keyFile, OutputStream keyOut) {
        try {
            keyOut.close();
        } catch (IOException e) {
            if (logger.isWarnEnabled()) {
                logger.warn("Failed to close a file: " + keyFile, e);
            }
        }
    }

    private static boolean isBouncyCastleAvailable() {
        try {
            // this class is in bcpkix, both fips and non-fips
            Class.forName("org.bouncycastle.cert.X509v3CertificateBuilder");
            return true;
        } catch (ClassNotFoundException e) {
            return false;
        }
    }

    public static final class Builder {
        // user fields
        String fqdn = "localhost";
        SecureRandom random;
        int bits = DEFAULT_KEY_LENGTH_BITS;
        Date notBefore = DEFAULT_NOT_BEFORE;
        Date notAfter = DEFAULT_NOT_AFTER;
        String algorithm = "RSA";

        // fields that are populated on demand
        Throwable failure;
        KeyPair keypair;
        PrivateKey privateKey;
        String[] paths;

        private Builder() {
        }

        /**
         * Set the fully-qualified domain name of the certificate that should be generated.
         *
         * @param fqdn The FQDN
         * @return This builder
         */
        public Builder fqdn(String fqdn) {
            this.fqdn = ObjectUtil.checkNotNullWithIAE(fqdn, "fqdn");
            return this;
        }

        /**
         * Set the RNG to use for key generation. This setting is not supported by the keytool-based generator.
         *
         * @param random The CSPRNG
         * @return This builder
         */
        public Builder random(SecureRandom random) {
            this.random = random;
            return this;
        }

        /**
         * Set the key size.
         *
         * @param bits The key size
         * @return This builder
         */
        public Builder bits(int bits) {
            this.bits = bits;
            return this;
        }

        /**
         * Set the start of the certificate validity period.
         *
         * @param notBefore The start date
         * @return This builder
         */
        public Builder notBefore(Date notBefore) {
            this.notBefore = ObjectUtil.checkNotNullWithIAE(notBefore, "notBefore");
            return this;
        }

        /**
         * Set the end of the certificate validity period.
         *
         * @param notAfter The start date
         * @return This builder
         */
        public Builder notAfter(Date notAfter) {
            this.notAfter = ObjectUtil.checkNotNullWithIAE(notAfter, "notAfter");
            return this;
        }

        /**
         * Set the key algorithm. Only RSA and EC are supported.
         *
         * @param algorithm The key algorithm
         * @return This builder
         */
        public Builder algorithm(String algorithm) {
            if ("EC".equalsIgnoreCase(algorithm)) {
                this.algorithm = "EC";
            } else if ("RSA".equalsIgnoreCase(algorithm)) {
                this.algorithm = "RSA";
            } else {
                throw new IllegalArgumentException("Algorithm not valid: " + algorithm);
            }
            return this;
        }

        private SecureRandom randomOrDefault() {
            // Bypass entropy collection by using insecure random generator.
            // We just want to generate it without any delay because it's for testing purposes only.
            return random == null ? ThreadLocalInsecureRandom.current() : random;
        }

        private void generateKeyPairLocally() {
            if (keypair != null) {
                return;
            }

            try {
                KeyPairGenerator keyGen = KeyPairGenerator.getInstance(algorithm);
                keyGen.initialize(bits, randomOrDefault());
                keypair = keyGen.generateKeyPair();
            } catch (NoSuchAlgorithmException e) {
                // Should not reach here because every Java implementation must have RSA and EC key pair generator.
                throw new IllegalStateException(e);
            }
            privateKey = keypair.getPrivate();
        }

        private void addFailure(Throwable t) {
            if (failure != null) {
                t.addSuppressed(failure);
            }
            failure = t;
        }

        boolean generateBc() {
            if (!isBouncyCastleAvailable()) {
                // no need to even try. We can avoid generating the key pair with this check.
                logger.debug("Failed to generate a self-signed X.509 certificate because " +
                        "BouncyCastle PKIX is not available in classpath");
                return false;
            }
            generateKeyPairLocally();
            try {
                // Try Bouncy Castle first as otherwise we will see an IllegalAccessError on more recent JDKs.
                paths = BouncyCastleSelfSignedCertGenerator.generate(
                        fqdn, keypair, randomOrDefault(), notBefore, notAfter, algorithm);
                return true;
            } catch (Throwable t) {
                logger.debug("Failed to generate a self-signed X.509 certificate using Bouncy Castle:", t);
                addFailure(t);
                return false;
            }
        }

        boolean generateKeytool() {
            if (!KeytoolSelfSignedCertGenerator.isAvailable()) {
                logger.debug("Not attempting to generate certificate with keytool because keytool is missing");
                return false;
            }
            if (random != null) {
                logger.debug("Not attempting to generate certificate with keytool because of explicitly set " +
                        "SecureRandom");
                return false;
            }
            try {
                KeytoolSelfSignedCertGenerator.generate(this);
                return true;
            } catch (Throwable t) {
                logger.debug("Failed to generate a self-signed X.509 certificate using keytool:", t);
                addFailure(t);
                return false;
            }
        }

        /**
         * Build the certificate. This builder must not be used again after this method is called.
         *
         * @return The certificate
         * @throws CertificateException If generation fails
         */
        public SelfSignedCertificate build() throws CertificateException {
            return new SelfSignedCertificate(this);
        }
    }
}
